Skip to content

[Security 4/9] MCP tools require confirmation unless positively known-safe - #247

Draft
amal66 wants to merge 7 commits into
Open-Legal-Products:mainfrom
amal66:olp-pr/sec-tool-confirm
Draft

[Security 4/9] MCP tools require confirmation unless positively known-safe#247
amal66 wants to merge 7 commits into
Open-Legal-Products:mainfrom
amal66:olp-pr/sec-tool-confirm

Conversation

@amal66

@amal66 amal66 commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

[Security 4/9] MCP tools require confirmation unless positively known-safe

Part of the split of #227 into single-topic PRs. Index: tracking comment on #227.

TL;DR

Flip the tool-confirmation default. Previously a connector tool ran without confirmation unless it declared itself destructive. Now a tool requires confirmation unless it is positively known-safe — i.e. it explicitly claims readOnlyHint: true, is not flagged destructive, and is not open-world. Anything absent or ambiguous is gated.

Risk to user data

Severity: medium–high, and this is a key defense-in-depth layer behind prompt injection (see [Security 5/9], spotlighting). Tool annotations (readOnlyHint / destructiveHint / openWorldHint) are advisory and controlled by the external MCP server — a hint, not a guarantee. If the LLM is tricked (or a connector is malicious/mis-annotated) into calling a side-effecting tool, the cost in a legal product is severe: exfiltrating a privileged document, mutating a matter, hitting an unknown external system. Requiring positive proof-of-safety means a bad or missing annotation fails toward a confirmation click, not silent execution.

Flows affected

  • MCP tool dispatch confirmation gating (toolRequiresConfirmation in mcp/client.ts).
  • User sees a confirmation prompt for any tool not provably read-only+closed-world.

Attack precedent

This is the "human in the loop for consequential actions" mitigation from the OWASP Top 10 for LLM Applications (excessive agency / insecure output handling). The general lesson — never trust a security decision to a field the other side controls — is the same failure behind capability-confused deputies.

Possible fixes, and what we chose

Option Verdict
Trust destructiveHint (old policy) A poorly- or maliciously-annotated tool omits the flag and runs unconfirmed. Rejected.
Gate on openWorldHint Almost every useful connector (Gmail, Slack, GitHub) is "open world", so this disables everything. Rejected as the sole signal.
Confirm everything, always Safe but so much friction that users habituate and click through — which trains them to approve blindly. Rejected.
Known-safe allowlist: confirm unless readOnlyHint===true && !destructive && !openWorld Chosen. The default flips toward safety; genuinely read-only, closed-world tools stay frictionless.
flowchart TD
    T["tool call requested"] --> Q{readOnlyHint === true<br/>AND not destructive<br/>AND not open-world?}
    Q -- yes --> Run["run without confirmation"]
    Q -- "no / missing / ambiguous" --> Ask["require user confirmation"]
    Ask -- approved --> Run
    Ask -- denied --> Stop["do not run"]
Loading

The subtlety encoded in the tests: readOnlyHint must be explicitly true — merely absent is treated as untrusted, so a tool that says nothing is gated, not trusted.

What's in this PR

  • backend/src/lib/mcp/client.tstoolRequiresConfirmation inverted to known-safe.
  • Tests: confirmation.test.ts (9 tests covering each annotation combination, incl. the missing-hint case).

Reading

OWASP Top 10 for LLM Applications · Confused deputy problem

@CLAassistant

CLAassistant commented Jul 23, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@amal66
amal66 force-pushed the olp-pr/sec-tool-confirm branch from 908e986 to ae67dad Compare July 25, 2026 21:31
@amal66
amal66 requested a review from willchen96 July 26, 2026 14:09
@amal66
amal66 marked this pull request as draft July 26, 2026 14:13
@amal66

amal66 commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author

Converting to draft — an adversarial re-review I ran found a confirmed regression: since no runtime confirmation flow exists yet, requires_confirmation=true acts as a permanent lockout (sync force-disables flagged tools, the toggle rejects enabling them), so defaulting to it would silently disable most existing connector tools on their next sync, with no backfill for already-synced rows. I'll rework this either as an explicit "default-disable unknown tools" policy or with a real confirmation flow before asking for review. Please skip this one for now.

@willchen96 willchen96 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don’t think this should merge in its current form. I found three blocking issues:

  1. There is no user-confirmation flow. toolRequiresConfirmation() sets requires_confirmation, but tools with that value are then disabled during refresh, their UI toggle is disabled, the backend rejects attempts to enable them, and both tool discovery and execution filter them out. The user never receives an approval prompt and has no way to approve a call. In practice, “confirmation required” currently means “permanently unavailable.”

  2. The MCP defaults are interpreted incorrectly. The MCP specification says an omitted openWorldHint defaults to true. However, the new implementation treats a missing value like false, so { readOnlyHint: true } is allowed to run automatically. If the policy requires a positively known closed-world tool, it must require openWorldHint === false explicitly, and the corresponding test should be reversed.

  3. External annotations are not proof that a tool is safe. The MCP server controls readOnlyHint, destructiveHint, and openWorldHint, and a malicious or incorrectly implemented server can lie. Custom or untrusted connectors should require per-call approval regardless of their annotations. Automatic execution should additionally require a locally controlled trust decision or reviewed allowlist.

To match the PR description, the implementation needs a real pending-call approval flow: store the exact proposed tool and arguments, show them to the user, bind approval to that user/chat/payload, make it short-lived and single-use, and execute only after approval.

Until that exists, the behavior should be described as “blocked by policy,” not “confirmation required.” The narrower classification fix should also require an explicit closed-world declaration:

const annotationSafe =
    annotations?.readOnlyHint === true &&
    annotations?.openWorldHint === false &&
    annotations?.destructiveHint !== true;

The backend build and test suite pass, but the tests currently validate the incorrect missing-openWorldHint behavior and do not test an end-to-end user approval because no such path exists.

amal66 added a commit to amal66/mike that referenced this pull request Aug 3, 2026
Addresses all three blocking issues from review on PR Open-Legal-Products#247: the missing
user-confirmation flow, the misread openWorldHint default, and treating
server-controlled annotations as proof of safety.

WHY THIS MATTERS
The previous commit classified tools as "requires confirmation" but gave
the user no way to confirm: gated tools were force-disabled on refresh,
their toggle was locked, and discovery/execution filtered them out.
"Confirmation required" in practice meant "permanently unavailable" —
which pushes users toward connectors with permissive (and unverifiable)
annotations, the opposite of the intended safety posture.

WHAT A REAL APPROVAL FLOW REQUIRES
An approval must be bound to exactly what will run, for exactly one run,
for a bounded time, decided by the right person. Anything looser decays
into a confirm-anything button:
- bound to the payload: approve THIS tool with THESE arguments, not
  "the next thing the model wants";
- single-use: an approval spends itself; it cannot authorize a replay;
- short-lived: a stale prompt found hours later must be inert;
- bound to the user: only the chat's owner can decide.

HOW IT WORKS
1. New user_mcp_pending_tool_calls table (RLS, service-role only). When
   the model proposes a gated call, the EXACT tool + arguments are
   stored as a pending row (TTL 2 min) and streamed to the chat UI as
   an mcp_confirmation_required event showing the stored payload.
2. The user clicks Approve/Decline → POST /user/mcp-pending-calls/:id
   carries ONLY the decision. The conditional UPDATE (id + user_id +
   status='pending' + not expired) makes the decision owner-bound and
   single-use; the payload cannot be altered through this endpoint.
3. The streaming chat turn waits (bounded, under the stream watchdog).
   On approval it claims the row via a second conditional UPDATE
   (approved -> executing, exactly one winner) and executes the STORED
   arguments read back from the row — never a value that arrived after
   the user saw the prompt. Denial/timeout returns an honest "declined
   / not approved in time" tool result to the model, and timeout
   retires the row so a late click cannot revive it.
4. Gated tools are now visible and enabled: refresh no longer disables
   them, the toggle works, and tool discovery advertises them to the
   model with a note that the user will be asked first.

ANNOTATION FIXES (review points 2 and 3)
- Per the MCP spec, an omitted openWorldHint defaults to TRUE, so the
  policy now requires an explicit closed-world declaration:
      readOnlyHint === true && openWorldHint === false
      && destructiveHint !== true
  { readOnlyHint: true } alone is an open-world reader and stays gated;
  the test asserting the old behavior is reversed accordingly.
- Annotations are server-controlled and are no longer sufficient for
  auto-execution. A tool runs unprompted only when BOTH independent
  signals agree: the annotations positively declare it safe AND the
  user has flipped the new per-connector "Trust this server's safety
  annotations" toggle (default off, stored in tool_policy, revocable).
  Untrusted connectors get per-call approval regardless of annotations.

TESTING
- confirmation.test.ts: reversed openWorldHint-omitted expectation per
  the spec, plus strict-type edges and both-signals approval matrix.
- approvals.test.ts: drives the real module against an in-memory query
  builder to prove ownership binding, decision single-use, expiry,
  exactly-one-winner execution claim, and timeout retirement.
- Backend: tsc clean, 284 passed / 5 skipped. Frontend: tsc clean;
  the pre-existing /account/api-keys prerender failure on this branch
  reproduces identically without these changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
amal66 added a commit to amal66/mike that referenced this pull request Aug 3, 2026
Addresses all three blocking issues from review on PR Open-Legal-Products#247: the missing
user-confirmation flow, the misread openWorldHint default, and treating
server-controlled annotations as proof of safety.

WHY THIS MATTERS
The previous commit classified tools as "requires confirmation" but gave
the user no way to confirm: gated tools were force-disabled on refresh,
their toggle was locked, and discovery/execution filtered them out.
"Confirmation required" in practice meant "permanently unavailable" —
which pushes users toward connectors with permissive (and unverifiable)
annotations, the opposite of the intended safety posture.

WHAT A REAL APPROVAL FLOW REQUIRES
An approval must be bound to exactly what will run, for exactly one run,
for a bounded time, decided by the right person. Anything looser decays
into a confirm-anything button:
- bound to the payload: approve THIS tool with THESE arguments, not
  "the next thing the model wants";
- single-use: an approval spends itself; it cannot authorize a replay;
- short-lived: a stale prompt found hours later must be inert;
- bound to the user: only the chat's owner can decide.

HOW IT WORKS
1. New user_mcp_pending_tool_calls table (RLS, service-role only). When
   the model proposes a gated call, the EXACT tool + arguments are
   stored as a pending row (TTL 2 min) and streamed to the chat UI as
   an mcp_confirmation_required event showing the stored payload.
2. The user clicks Approve/Decline → POST /user/mcp-pending-calls/:id
   carries ONLY the decision. The conditional UPDATE (id + user_id +
   status='pending' + not expired) makes the decision owner-bound and
   single-use; the payload cannot be altered through this endpoint.
3. The streaming chat turn waits (bounded, under the stream watchdog).
   On approval it claims the row via a second conditional UPDATE
   (approved -> executing, exactly one winner) and executes the STORED
   arguments read back from the row — never a value that arrived after
   the user saw the prompt. Denial/timeout returns an honest "declined
   / not approved in time" tool result to the model, and timeout
   retires the row so a late click cannot revive it.
4. Gated tools are now visible and enabled: refresh no longer disables
   them, the toggle works, and tool discovery advertises them to the
   model with a note that the user will be asked first.

ANNOTATION FIXES (review points 2 and 3)
- Per the MCP spec, an omitted openWorldHint defaults to TRUE, so the
  policy now requires an explicit closed-world declaration:
      readOnlyHint === true && openWorldHint === false
      && destructiveHint !== true
  { readOnlyHint: true } alone is an open-world reader and stays gated;
  the test asserting the old behavior is reversed accordingly.
- Annotations are server-controlled and are no longer sufficient for
  auto-execution. A tool runs unprompted only when BOTH independent
  signals agree: the annotations positively declare it safe AND the
  user has flipped the new per-connector "Trust this server's safety
  annotations" toggle (default off, stored in tool_policy, revocable).
  Untrusted connectors get per-call approval regardless of annotations.

TESTING
- confirmation.test.ts: reversed openWorldHint-omitted expectation per
  the spec, plus strict-type edges and both-signals approval matrix.
- approvals.test.ts: drives the real module against an in-memory query
  builder to prove ownership binding, decision single-use, expiry,
  exactly-one-winner execution claim, and timeout retirement.
- Backend: tsc clean, 284 passed / 5 skipped. Frontend: tsc clean;
  the pre-existing /account/api-keys prerender failure on this branch
  reproduces identically without these changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@amal66
amal66 force-pushed the olp-pr/sec-tool-confirm branch from acf3a65 to 2286295 Compare August 3, 2026 01:57
amal66 added a commit to amal66/mike that referenced this pull request Aug 6, 2026
Review finding on PR Open-Legal-Products#247: the fail-safe confirmation policy was only
applied when writing the requires_confirmation column during a manual
tool refresh. Rows cached before this PR (under the old, lenient
policy) keep requires_confirmation=false — so the moment a user enables
the trust-annotations toggle, a stale unannotated/open-world tool would
auto-run without approval, violating the PR's own invariant.

WHY THIS MATTERS
A security gate that reads a cached verdict is only as strong as the
oldest cache entry. The policy says "a tool requires per-call
confirmation UNLESS its annotations positively declare it safe", but
the column that stored that verdict was written by whatever policy was
in force at refresh time. Nothing forces users to hit "refresh tools"
after upgrading, so the strict policy silently did not apply to
exactly the tools it was written for: pre-existing ones.

WHAT IS A STALE-CACHE BYPASS
A derived value (here: requires_confirmation, derived from the
server's annotations) is stored for convenience, and a later, stricter
derivation rule ships — but consumers keep reading the stored value.
The rule change then only protects data written after the change. The
standard fix is to make the AUTHORITATIVE decision recompute from the
source data (the annotations jsonb, which is stored alongside and
already selected) and demote the column to a display cache.

HOW THE FIX WORKS
A new helper is the single call-time gate over a cached tool row:

    export function toolRowRequiresConfirmation(row) {
        return (
            row.requires_confirmation === true ||
            toolRequiresConfirmation(row.annotations)
        );
    }

- The live recomputation from annotations is authoritative: a stale
  `false` next to ambiguous annotations still gates.
- An explicit stored `true` is still honored, so a cached "gate this"
  can never be silently downgraded either. The gate can only ever be
  strictly tighter than either signal alone.

Both gate sites now use it: resolveCallableTool (execution) already
selected the full row; buildUserMcpTools (tool advertisement) now
selects the annotations column too. refreshUserMcpConnectorTools still
writes the column — it remains useful for display/API summaries.

Tests: unit tests for the helper's three cases, plus an end-to-end test
driving executeMcpToolCall against an in-memory Supabase stand-in with
the exact regression row (requires_confirmation=false, annotations={},
trusted connector): the call must pause for approval, expire when none
arrives, and never reach execution. The connector's URL is a private IP
on purpose — if the gate ever lets the call through, the SSRF guard
error (not an approval pause) makes the failure mode obvious.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
amal66 added a commit to amal66/mike that referenced this pull request Aug 6, 2026
Review finding on PR Open-Legal-Products#247: waitForMcpApprovalDecision breaks out of its
poll loop on deadline and then runs the pending -> expired UPDATE. If
the user's decision commits in the gap between those two steps, the
conditional UPDATE matches nothing (the row is no longer `pending`) —
the row stays `approved` forever while the caller reports "expired" to
the model and the user.

WHY THIS MATTERS
The pending-call ledger is the single source of truth for what the
user authorized. This race desynchronizes it from reality in the worst
direction for an audit trail: the database says the user approved a
call, the chat says it expired, and nothing ever executed. The
stranded `approved` row is also outside the state machine's expected
flow — decideMcpPendingToolCall's single-use guard means no later
action will ever move it again.

WHAT IS A CHECK-THEN-ACT RACE
Any "read state, then act on what you read" sequence is racy when
another writer can slip between the read and the act. Here the read is
the final status poll ("still pending") and the act is the expiry
UPDATE. Databases give you the tool to close it: make the ACT itself
conditional (UPDATE ... WHERE status = 'pending') and look at how many
rows it changed. The affected-row count is an atomic answer to "was my
read still true when I acted?" — no extra locking needed.

HOW THE FIX WORKS
    const { data: expired } = await db
        .from("user_mcp_pending_tool_calls")
        .update({ status: "expired" })
        .eq("id", pendingId)
        .eq("status", "pending")
        .select("id");          // <- affected rows, the race detector
    if (expired.length > 0) return "expired";  // we really expired it
    // 0 rows: a decision won the race — re-read once and honor it.

If the expiry UPDATE changed a row, we expired a genuinely undecided
call, exactly as before. If it changed nothing, a decision landed in
the gap; one re-read returns "approved"/"denied" so the caller's
answer matches the ledger. Statuses other than approved/denied on the
re-read (e.g. another waiter already expired it) still report expired.

Test: a wrapper around the in-memory db flips the row to `approved`
immediately after the first status poll resolves — deterministically
reproducing the gap — and asserts the waiter returns "approved" and
the ledger keeps the user's decision.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
amal66 added a commit to amal66/mike that referenced this pull request Aug 6, 2026
Review finding on PR Open-Legal-Products#247: markMcpToolCallExecuted ran BEFORE the MCP
call was made, so the pending-call ledger said status='executed' even
when the call then failed (network error, server rejection, SSRF
guard). In a legal product the approval ledger doubles as an audit
trail of what ran against matter data — it must not record outcomes
that never happened.

WHY THIS MATTERS
There were two separate jobs squeezed into one status write:
1. SAFETY: make an approval single-use, i.e. ensure no second caller
   can execute the same approved payload. This must happen BEFORE the
   call — claiming after execution would leave a window for a replay.
2. TRUTH: record what happened. This can only be known AFTER the call.
Writing 'executed' up front handled job 1 but sacrificed job 2. The
fix keeps them apart so neither compromises the other.

WHAT IS CLAIM-THEN-RECORD
A standard pattern for exactly-once side effects against shared state:
first CLAIM the work with an atomic conditional transition to an
intermediate state (approved -> executing; only one caller can win),
then perform the side effect, then record the real outcome as a
terminal state. The intermediate state is honest at every instant:
'executing' means "an attempt is in flight", never "it succeeded".

HOW THE FIX WORKS
The state machine gains a terminal 'failed' state:
    pending -> approved -> executing -> executed  (call completed)
    pending -> approved -> executing -> failed    (call errored)

- claimApprovedMcpToolCall (approved -> executing) is unchanged and
  still runs before execution: single-use safety is preserved, and a
  failed attempt stays spent — 'failed' is terminal, so an approval
  can never be retried or replayed after an error.
- The premature markMcpToolCallExecuted call is gone. executeMcpToolCall
  remembers the claimed row id and writes the truth at the end:
      const result = await withMcpClient(...);   // the actual call
      if (claimedPendingId) await markMcpToolCallExecuted(...);
  and in the catch path:
      if (claimedPendingId) await markMcpToolCallFailed(...);
- Both terminal writes are conditional UPDATEs requiring status =
  'executing', so terminal states are one-way and mutually exclusive.
- executed_at now means "when the attempt finished" for both outcomes.

The migration's CHECK constraint and the PendingToolCallRow type gain
'failed'. (The migration ships in this same unmerged PR, so widening
its CHECK here is safe — no deployed database has the old constraint.)

Tests: state-machine tests prove executing -> executed / failed each
fire only from a claimed row and never overwrite each other; an
end-to-end test approves a call whose execution then fails at the SSRF
guard and asserts the ledger reads 'failed', not 'executed'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
amal66 added a commit to amal66/mike that referenced this pull request Aug 6, 2026
Review finding on PR Open-Legal-Products#247: terminal ledger rows (executed / failed /
denied / expired) were retained forever, and each one carries the full
tool-argument jsonb the model proposed — in a legal product that can be
privileged matter data (case ids, document excerpts, party names)
sitting in a table with no cleanup path.

WHY THIS MATTERS
Data-minimization is part of the security posture: every copy of
sensitive data is another thing a breach, a misconfigured export, or an
over-broad query can leak. The ledger's arguments payload exists for
exactly one purpose — showing the user what they are approving and
executing precisely that — and that purpose is over within minutes.
Terminal rows keep only short-term forensic value ("what ran today?"),
so they get a bounded lifetime (24h) instead of an unbounded one.

WHAT IS AN OPPORTUNISTIC SWEEP
Instead of standing up cron/job infrastructure for a low-traffic
table, cleanup piggybacks on an operation that already touches the
table: every new pending-call INSERT first deletes terminal rows older
than the retention window. The repo already uses insert-time cleanup
for the OAuth state store (saveCodeVerifier deletes the stale state
row before inserting the new one); this follows the same shape. The
table only grows while the approval flow is being used — which is
precisely when the sweep runs.

HOW THE FIX WORKS
    await db.from("user_mcp_pending_tool_calls")
        .delete()
        .in("status", ["executed", "failed", "denied", "expired"])
        .lt("created_at", cutoff);   // now - 24h

- Only TERMINAL statuses are eligible: pending/approved/executing rows
  are the approval flow's live state and are never deleted, whatever
  their age (they are bounded anyway — expires_at retires them to a
  terminal state within minutes).
- Best-effort: a sweep failure is logged and swallowed, because the
  user is waiting on the approval prompt the insert serves; the next
  insert retries the sweep.
- The sweep is deliberately global (not per-user): the service-role
  backend is the only writer, and an active user's insert also clears
  other users' aged-out rows, so retention holds even for users who
  never return.

Test: an insert with aged executed/expired rows, a recent denied row,
and an old-but-live pending row on the table deletes exactly the aged
terminal rows and nothing else.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
amal66 and others added 7 commits August 5, 2026 20:36
Split out of the security pack: the tool-confirmation half of 4c44c15.
Adapted-from: Open-Legal-Products#227
Addresses all three blocking issues from review on PR Open-Legal-Products#247: the missing
user-confirmation flow, the misread openWorldHint default, and treating
server-controlled annotations as proof of safety.

WHY THIS MATTERS
The previous commit classified tools as "requires confirmation" but gave
the user no way to confirm: gated tools were force-disabled on refresh,
their toggle was locked, and discovery/execution filtered them out.
"Confirmation required" in practice meant "permanently unavailable" —
which pushes users toward connectors with permissive (and unverifiable)
annotations, the opposite of the intended safety posture.

WHAT A REAL APPROVAL FLOW REQUIRES
An approval must be bound to exactly what will run, for exactly one run,
for a bounded time, decided by the right person. Anything looser decays
into a confirm-anything button:
- bound to the payload: approve THIS tool with THESE arguments, not
  "the next thing the model wants";
- single-use: an approval spends itself; it cannot authorize a replay;
- short-lived: a stale prompt found hours later must be inert;
- bound to the user: only the chat's owner can decide.

HOW IT WORKS
1. New user_mcp_pending_tool_calls table (RLS, service-role only). When
   the model proposes a gated call, the EXACT tool + arguments are
   stored as a pending row (TTL 2 min) and streamed to the chat UI as
   an mcp_confirmation_required event showing the stored payload.
2. The user clicks Approve/Decline → POST /user/mcp-pending-calls/:id
   carries ONLY the decision. The conditional UPDATE (id + user_id +
   status='pending' + not expired) makes the decision owner-bound and
   single-use; the payload cannot be altered through this endpoint.
3. The streaming chat turn waits (bounded, under the stream watchdog).
   On approval it claims the row via a second conditional UPDATE
   (approved -> executing, exactly one winner) and executes the STORED
   arguments read back from the row — never a value that arrived after
   the user saw the prompt. Denial/timeout returns an honest "declined
   / not approved in time" tool result to the model, and timeout
   retires the row so a late click cannot revive it.
4. Gated tools are now visible and enabled: refresh no longer disables
   them, the toggle works, and tool discovery advertises them to the
   model with a note that the user will be asked first.

ANNOTATION FIXES (review points 2 and 3)
- Per the MCP spec, an omitted openWorldHint defaults to TRUE, so the
  policy now requires an explicit closed-world declaration:
      readOnlyHint === true && openWorldHint === false
      && destructiveHint !== true
  { readOnlyHint: true } alone is an open-world reader and stays gated;
  the test asserting the old behavior is reversed accordingly.
- Annotations are server-controlled and are no longer sufficient for
  auto-execution. A tool runs unprompted only when BOTH independent
  signals agree: the annotations positively declare it safe AND the
  user has flipped the new per-connector "Trust this server's safety
  annotations" toggle (default off, stored in tool_policy, revocable).
  Untrusted connectors get per-call approval regardless of annotations.

TESTING
- confirmation.test.ts: reversed openWorldHint-omitted expectation per
  the spec, plus strict-type edges and both-signals approval matrix.
- approvals.test.ts: drives the real module against an in-memory query
  builder to prove ownership binding, decision single-use, expiry,
  exactly-one-winner execution claim, and timeout retirement.
- Backend: tsc clean, 284 passed / 5 skipped. Frontend: tsc clean;
  the pre-existing /account/api-keys prerender failure on this branch
  reproduces identically without these changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review finding on PR Open-Legal-Products#247: the fail-safe confirmation policy was only
applied when writing the requires_confirmation column during a manual
tool refresh. Rows cached before this PR (under the old, lenient
policy) keep requires_confirmation=false — so the moment a user enables
the trust-annotations toggle, a stale unannotated/open-world tool would
auto-run without approval, violating the PR's own invariant.

WHY THIS MATTERS
A security gate that reads a cached verdict is only as strong as the
oldest cache entry. The policy says "a tool requires per-call
confirmation UNLESS its annotations positively declare it safe", but
the column that stored that verdict was written by whatever policy was
in force at refresh time. Nothing forces users to hit "refresh tools"
after upgrading, so the strict policy silently did not apply to
exactly the tools it was written for: pre-existing ones.

WHAT IS A STALE-CACHE BYPASS
A derived value (here: requires_confirmation, derived from the
server's annotations) is stored for convenience, and a later, stricter
derivation rule ships — but consumers keep reading the stored value.
The rule change then only protects data written after the change. The
standard fix is to make the AUTHORITATIVE decision recompute from the
source data (the annotations jsonb, which is stored alongside and
already selected) and demote the column to a display cache.

HOW THE FIX WORKS
A new helper is the single call-time gate over a cached tool row:

    export function toolRowRequiresConfirmation(row) {
        return (
            row.requires_confirmation === true ||
            toolRequiresConfirmation(row.annotations)
        );
    }

- The live recomputation from annotations is authoritative: a stale
  `false` next to ambiguous annotations still gates.
- An explicit stored `true` is still honored, so a cached "gate this"
  can never be silently downgraded either. The gate can only ever be
  strictly tighter than either signal alone.

Both gate sites now use it: resolveCallableTool (execution) already
selected the full row; buildUserMcpTools (tool advertisement) now
selects the annotations column too. refreshUserMcpConnectorTools still
writes the column — it remains useful for display/API summaries.

Tests: unit tests for the helper's three cases, plus an end-to-end test
driving executeMcpToolCall against an in-memory Supabase stand-in with
the exact regression row (requires_confirmation=false, annotations={},
trusted connector): the call must pause for approval, expire when none
arrives, and never reach execution. The connector's URL is a private IP
on purpose — if the gate ever lets the call through, the SSRF guard
error (not an approval pause) makes the failure mode obvious.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review finding on PR Open-Legal-Products#247: waitForMcpApprovalDecision breaks out of its
poll loop on deadline and then runs the pending -> expired UPDATE. If
the user's decision commits in the gap between those two steps, the
conditional UPDATE matches nothing (the row is no longer `pending`) —
the row stays `approved` forever while the caller reports "expired" to
the model and the user.

WHY THIS MATTERS
The pending-call ledger is the single source of truth for what the
user authorized. This race desynchronizes it from reality in the worst
direction for an audit trail: the database says the user approved a
call, the chat says it expired, and nothing ever executed. The
stranded `approved` row is also outside the state machine's expected
flow — decideMcpPendingToolCall's single-use guard means no later
action will ever move it again.

WHAT IS A CHECK-THEN-ACT RACE
Any "read state, then act on what you read" sequence is racy when
another writer can slip between the read and the act. Here the read is
the final status poll ("still pending") and the act is the expiry
UPDATE. Databases give you the tool to close it: make the ACT itself
conditional (UPDATE ... WHERE status = 'pending') and look at how many
rows it changed. The affected-row count is an atomic answer to "was my
read still true when I acted?" — no extra locking needed.

HOW THE FIX WORKS
    const { data: expired } = await db
        .from("user_mcp_pending_tool_calls")
        .update({ status: "expired" })
        .eq("id", pendingId)
        .eq("status", "pending")
        .select("id");          // <- affected rows, the race detector
    if (expired.length > 0) return "expired";  // we really expired it
    // 0 rows: a decision won the race — re-read once and honor it.

If the expiry UPDATE changed a row, we expired a genuinely undecided
call, exactly as before. If it changed nothing, a decision landed in
the gap; one re-read returns "approved"/"denied" so the caller's
answer matches the ledger. Statuses other than approved/denied on the
re-read (e.g. another waiter already expired it) still report expired.

Test: a wrapper around the in-memory db flips the row to `approved`
immediately after the first status poll resolves — deterministically
reproducing the gap — and asserts the waiter returns "approved" and
the ledger keeps the user's decision.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review finding on PR Open-Legal-Products#247: markMcpToolCallExecuted ran BEFORE the MCP
call was made, so the pending-call ledger said status='executed' even
when the call then failed (network error, server rejection, SSRF
guard). In a legal product the approval ledger doubles as an audit
trail of what ran against matter data — it must not record outcomes
that never happened.

WHY THIS MATTERS
There were two separate jobs squeezed into one status write:
1. SAFETY: make an approval single-use, i.e. ensure no second caller
   can execute the same approved payload. This must happen BEFORE the
   call — claiming after execution would leave a window for a replay.
2. TRUTH: record what happened. This can only be known AFTER the call.
Writing 'executed' up front handled job 1 but sacrificed job 2. The
fix keeps them apart so neither compromises the other.

WHAT IS CLAIM-THEN-RECORD
A standard pattern for exactly-once side effects against shared state:
first CLAIM the work with an atomic conditional transition to an
intermediate state (approved -> executing; only one caller can win),
then perform the side effect, then record the real outcome as a
terminal state. The intermediate state is honest at every instant:
'executing' means "an attempt is in flight", never "it succeeded".

HOW THE FIX WORKS
The state machine gains a terminal 'failed' state:
    pending -> approved -> executing -> executed  (call completed)
    pending -> approved -> executing -> failed    (call errored)

- claimApprovedMcpToolCall (approved -> executing) is unchanged and
  still runs before execution: single-use safety is preserved, and a
  failed attempt stays spent — 'failed' is terminal, so an approval
  can never be retried or replayed after an error.
- The premature markMcpToolCallExecuted call is gone. executeMcpToolCall
  remembers the claimed row id and writes the truth at the end:
      const result = await withMcpClient(...);   // the actual call
      if (claimedPendingId) await markMcpToolCallExecuted(...);
  and in the catch path:
      if (claimedPendingId) await markMcpToolCallFailed(...);
- Both terminal writes are conditional UPDATEs requiring status =
  'executing', so terminal states are one-way and mutually exclusive.
- executed_at now means "when the attempt finished" for both outcomes.

The migration's CHECK constraint and the PendingToolCallRow type gain
'failed'. (The migration ships in this same unmerged PR, so widening
its CHECK here is safe — no deployed database has the old constraint.)

Tests: state-machine tests prove executing -> executed / failed each
fire only from a claimed row and never overwrite each other; an
end-to-end test approves a call whose execution then fails at the SSRF
guard and asserts the ledger reads 'failed', not 'executed'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review finding on PR Open-Legal-Products#247: terminal ledger rows (executed / failed /
denied / expired) were retained forever, and each one carries the full
tool-argument jsonb the model proposed — in a legal product that can be
privileged matter data (case ids, document excerpts, party names)
sitting in a table with no cleanup path.

WHY THIS MATTERS
Data-minimization is part of the security posture: every copy of
sensitive data is another thing a breach, a misconfigured export, or an
over-broad query can leak. The ledger's arguments payload exists for
exactly one purpose — showing the user what they are approving and
executing precisely that — and that purpose is over within minutes.
Terminal rows keep only short-term forensic value ("what ran today?"),
so they get a bounded lifetime (24h) instead of an unbounded one.

WHAT IS AN OPPORTUNISTIC SWEEP
Instead of standing up cron/job infrastructure for a low-traffic
table, cleanup piggybacks on an operation that already touches the
table: every new pending-call INSERT first deletes terminal rows older
than the retention window. The repo already uses insert-time cleanup
for the OAuth state store (saveCodeVerifier deletes the stale state
row before inserting the new one); this follows the same shape. The
table only grows while the approval flow is being used — which is
precisely when the sweep runs.

HOW THE FIX WORKS
    await db.from("user_mcp_pending_tool_calls")
        .delete()
        .in("status", ["executed", "failed", "denied", "expired"])
        .lt("created_at", cutoff);   // now - 24h

- Only TERMINAL statuses are eligible: pending/approved/executing rows
  are the approval flow's live state and are never deleted, whatever
  their age (they are bounded anyway — expires_at retires them to a
  terminal state within minutes).
- Best-effort: a sweep failure is logged and swallowed, because the
  user is waiting on the approval prompt the insert serves; the next
  insert retries the sweep.
- The sweep is deliberately global (not per-user): the service-role
  backend is the only writer, and an active user's insert also clears
  other users' aged-out rows, so retention holds even for users who
  never return.

Test: an insert with aged executed/expired rows, a recent denied row,
and an old-but-live pending row on the table deletes exactly the aged
terminal rows and nothing else.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
WHY THIS MATTERS
Since PR Open-Legal-Products#256, backend/schema.sql is the single authoritative bootstrap for a
fresh database: local stacks and the Supabase-backed test harness create every
table from this one snapshot, and migrations exist only to upgrade databases
that already ran an older snapshot. A table that lives only in a migration
therefore never exists on a fresh install — the approval ledger would be
missing exactly where new deployments and CI create their schema, and every
approval-gated MCP tool call would fail at the insert into
user_mcp_pending_tool_calls.

WHAT IS THE SCHEMA SNAPSHOT / MIGRATION SPLIT
backend/schema.sql describes the complete current schema (create table if not
exists, indexes, RLS, and the revoke block that keeps browser roles out of
backend-only tables). backend/migrations/*.sql are ordered deltas for
already-provisioned databases. Every commit on main that adds a table adds it
in BOTH places (e.g. 562a813); this branch predates that convention being
load-bearing, so its migration 20260802_01_mcp_pending_tool_calls.sql had no
snapshot counterpart.

HOW THE FIX WORKS
Adds public.user_mcp_pending_tool_calls to schema.sql exactly as the migration
defines it — same columns, one-way status check constraint, user and
(status, expires_at) indexes, and RLS enabled with no browser policies — in the
snapshot's lowercase style, plus the matching "revoke all ... from anon,
authenticated" line so the ledger (whose rows carry raw tool arguments, i.e.
potentially privileged matter data) is reachable only through the service-role
backend like the other MCP tables. No behavior change on databases that
already ran the migration: both files use IF NOT EXISTS.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants